Skip to content

feat(tools): inject the tools MCP into the session CLI config at spawn (#39) - #45

Closed
Reese-max wants to merge 2 commits into
openabdev:mainfrom
Reese-max:devin/issue-39
Closed

Reese-max wants to merge 2 commits into
openabdev:mainfrom
Reese-max:devin/issue-39

Conversation

@Reese-max

Copy link
Copy Markdown
Contributor

Refs #39

Summary

OPENAB_TOOLS_MCP_URL put the loopback endpoint in the child's environment,
but wiring it into the CLI was manual: mcp add per session plus a
hand-edited allowlist, and both hard-coded a URL whose key dies on the next
spawn. At spawn the runtime now writes the session-independent form for
the CLI variants it knows — kiro-cli first:

  • ~/.local/share/openab-pty/computer-mcp-bridge.js — a small stdio bridge
    embedded in the binary and rewritten each spawn; every stdin line is one
    JSON-RPC message POSTed to $OPENAB_TOOLS_MCP_URL (JSON and SSE replies,
    Mcp-Session-Id echoed, per-id JSON-RPC errors on HTTP failure).
  • ~/.kiro/settings/mcp.json gains exactly the computer server
    {command: <bundled bun / PATH bun / node>=18, args: [bridge], env: {OPENAB_TOOLS_MCP_URL: "${OPENAB_TOOLS_MCP_URL}"}} — every other
    server and setting is preserved, and the file never holds a URL or key, so
    the shared workspace config needs no refresh when the key rotates.
  • every ~/.kiro/agents/*.json gains @computer/* in allowedTools, plus
    @computer in a restrictive tools list and a computer entry in
    mcpServers for agents that opt out of the shared mcp.json. Agent files
    are merged, never created.
  • Writes are atomic (sibling tempfile + rename, write through resolvable
    symlinks, preserve existing file modes). Malformed, non-object, or
    foreign-shaped files are left byte-identical; dangling symlinks are left
    alone rather than replaced.
  • Unknown CLI variant → nothing is written and the §9.3 env-var manual path
    stands. tools_listen unset → injection never runs. A failed merge warns
    and never costs a spawn.

Detection: kiro-cli on the child's PATH or under the installer's
~/.local layout; JS runtime = bundled bun → ~/.local/share/kiro-cli/bun
→ PATH bun → PATH node (with a --version probe, node ≥ 18 required for
fetch).

Also includes a preparatory commit hoisting the crate into a repo-root
Cargo workspace (Cargo.toml/Cargo.lock at root), so cargo test/fmt/clippy --workspace work from a checkout root as well as from
runtime/; Dockerfiles, CI cache, and dependabot updated to match.

Test plan

  • cargo fmt --all -- --check
  • cargo test --workspace (175 unit + 13 config tests incl. injection
    merge/preservation, unknown-variant, tools-off, node<18, dangling
    symlink, mode-preservation, and restart-rotation byte-identical)
  • cargo clippy --workspace --all-targets -- -D warnings
  • bridge e2e (ignored): real node process + real HTTP stub — JSON-RPC
    round-trip, SSE multi-line normalization, notification silence
  • session tests: fresh tools spawn injects zero-step config; restart
    rotates the env key while the file stays byte-identical; unwritable
    workspace still spawns

Docs: runtime/CLIENT-CONTRACT.md §9.3 rewritten for the auto-injection +
manual fallback; docs/k8s-howto.md §6 notes the zero-step CLI side.

Generated with Devin

devin-ai-integration Bot and others added 2 commits September 30, 2026 09:13
The crate used to be its own workspace rooted at runtime/, so cargo only
worked from inside that directory: `cargo test --workspace` at the repo
root had no manifest to find. Moving the workspace manifest (and the lock)
to the root makes the whole-repo commands behave the way every other Rust
repo's do, while `cd runtime && cargo …` keeps working through the member.

Dockerfile and Dockerfile.nightly pick up the new manifest/lock layout;
dependabot and the CI cache now look at the root workspace.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
openabdev#39)

OPENAB_TOOLS_MCP_URL put the loopback endpoint in the child's environment,
but wiring it into the CLI was manual: `mcp add` per session plus a
hand-edited allowlist, and both hard-coded a URL whose key dies on the
next spawn. At spawn the runtime now writes the session-independent form
for the CLI variants it knows — kiro-cli first:

- ~/.kiro/settings/mcp.json gains a `computer` server that runs a small
  stdio bridge on the installer's bundled bun (or node). The bridge reads
  the URL from *its* environment — `${OPENAB_TOOLS_MCP_URL}` in `env`, the
  one place kiro expands variables — so the shared workspace file serves
  every session and never holds a key. A hard-coded per-session URL was
  the live failure in the field report: three sessions, one computer.
- every ~/.kiro/agents/*.json gains `@computer/*` in allowedTools — the
  sandbox-served set under the platform-neutral alias — plus `@computer`
  in a restrictive `tools` list, and an agent that opted out of mcp.json
  gets its own copy of the server entry. Agent files are merged in place,
  never created.
- nothing is written for a CLI the runtime does not know, when the tools
  plane is off, or when no JS runtime exists; the §9.3 env-var manual path
  stands in all three, and a failed merge never costs a spawn.

Refs openabdev#39

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@chaodu-agent

Copy link
Copy Markdown
Collaborator

Thank you @Reese-max — this PR is what made the shape of #39 clear, and its merge rules carry over directly. We're closing it in favour of #49, which keeps your approach to the config merge but changes where it lives and what it writes. You're credited as co-author on #49's commit (Co-authored-by: Reese-max), and the PR description says so.

Why a new PR instead of review rounds:

  1. feat(tools): one static /mcp endpoint, session chosen by bearer key #44 superseded the bridge. Once POST /mcp + Authorization: Bearer ${OPENAB_TOOLS_MCP_TOKEN} existed (kiro expands ${VAR} in headers), the session-independent config needs no stdio bridge, no bun/node discovery and no second MCP transport.
  2. CLI knowledge belongs in the image, not the runtime. There are a dozen variants, each built for one CLI. feat(tools): wire the tools MCP into the CLI from an image startup hook #49 adds a generic --startup-hook to the runtime and puts the kiro wiring in a shell + jq hook the image ships, so a new CLI is a function there rather than a runtime release. It also runs after seeding, so a seeded mcp.json is edited rather than overwriting the edit.
  3. Smaller things we'd have asked for anyway: leave restrictive tools lists alone, keep key order, split out the workspace hoist, and the macOS /private/var assertion failures.

What carried over from your work: preserving other servers/settings, leaving malformed or wrong-shaped files byte-identical, never creating agent files, leaving dangling symlinks alone, atomic writes that keep the mode, and the @computer/* trust rule. Review on #49 is very welcome.

thepagent pushed a commit that referenced this pull request Oct 1, 2026
…ok (#49)

* feat(tools): wire the tools MCP into the CLI from an image startup hook (#39)

The runtime keeps knowing no coding CLI's config format. Instead it gains one
generic seam, `--startup-hook <FILE>`: an image-provided executable it runs
once, after seeding and before serving, best-effort (failure, non-zero exit or
a 30 s timeout is logged and serving continues). After seeding matters: a seed
archive may carry the very config file the hook edits.

The images ship deploy/hooks/startup-hook.sh (bash + jq; jq added to both
Dockerfiles). For kiro-cli, when PTY_TOOLS_LISTEN is set, it writes #44's
session-independent form into ~/.kiro/settings/mcp.json:

  computer = { url: http://<PTY_TOOLS_LISTEN>/mcp,
               headers: { Authorization: "Bearer ${OPENAB_TOOLS_MCP_TOKEN}" } }

and adds @computer/* to allowedTools in existing agent files. No session name
or key is ever on disk, so one shared workspace config serves every session
and survives every key rotation. With the tools plane off, only the entry the
hook wrote is withdrawn. A new CLI is a function in the hook, not a runtime
change.

Merge rules come from #45: other servers/settings preserved, wrong-shaped or
malformed files left byte-identical, agent files never created, dangling
symlinks left alone, atomic writes that keep the file's mode. Changed from
#45: no stdio JS bridge (superseded by #44's header route), restrictive
`tools` lists and `mcpServers` in agent files are not touched, key order is
kept, and the knowledge lives in the image rather than the runtime.

Closes #39. Supersedes #45.

Co-authored-by: Reese-max <198568376+Reese-max@users.noreply.github.com>

* fix(hook): scrub the hook's env, kill and reap its group, respect user entries

Review round 1 on #49.

- B1: the hook ran with the runtime's full environment while jq loaded
  ~/.jq from the shared workspace, so a planted ~/.jq could write e.g. an
  ECS credentials URI into a config the next session reads. The hook now
  gets env_clear() + the session allowlist + OPENAB_PTY_TOOLS_LISTEN, and
  the script runs jq with HOME=/nonexistent.
- S1: the hook leads its own process group; on exit or timeout the group
  is SIGKILLed and reparented members are reaped (waitpid(-pgid)), so no
  orphan or zombie outlives it.
- S2: main.rs documents why the exec'd, dumpable hook is safe (scrubbed
  env, no session yet). Inline step numbers fixed.
- S3: the hook runs after the binds and is handed the tools address the
  runtime actually bound (same as OPENAB_TOOLS_MCP_ENDPOINT), not
  PTY_TOOLS_LISTEN re-read from env. IPv6 tested.
- S4: tools-on replaces `computer` only when absent, ours (by header), or a
  hand-wired URL on this listener; anything else is left with a warning.
- S5: a failed temp-file write is never renamed over the config.
- Nits: non-regular paths and multi-document files left alone; HOME unset
  exits 0; "*" in allowedTools counts as trusted; WaitFailed outcome;
  jq version noted in the Dockerfile; `jq --version` in CI; tests for all
  of the above (39 shell cases, 8 hook tests incl. children and background
  members). Test-only ETXTBSY retry for scripts exec'd right after writing.

* fix(hook): never fatal on a JoinError, tolerate non-UTF-8 env at boot

Review round 2 nits on #49.

- A failed spawn_blocking task (panic inside it) was propagated with `?`,
  contradicting the hook's best-effort contract; it is now a warning.
- std::env::vars() panics on a non-UTF-8 variable, which with
  --startup-hook would have aborted the runtime at boot. Use vars_os()
  and drop variables that are not UTF-8 (the allowlist is ASCII anyway).

Verified: runtime started with BAD=\xff\xfe in its env and a hook, hook
ran and saw OPENAB_PTY_TOOLS_LISTEN, runtime kept serving.

---------

Co-authored-by: Reese-max <198568376+Reese-max@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants